Skip to content

feat(slack-app phase 2): Slack OAuth install flow + workspace persistence#26

Merged
TS00 merged 2 commits intodevfrom
feat/slack-oauth-phase2
Apr 28, 2026
Merged

feat(slack-app phase 2): Slack OAuth install flow + workspace persistence#26
TS00 merged 2 commits intodevfrom
feat/slack-oauth-phase2

Conversation

@TS00
Copy link
Copy Markdown
Collaborator

@TS00 TS00 commented Apr 28, 2026

Summary

Phase 2 of the Slack app (full design: `docs/eng-plan-slack-app-v1.md`, parent memory `d959bc61`). Stands up:

  • Slack OAuth install handshake.
  • Workspace persistence with encrypted bot tokens.
  • Four HTTP routes the dashboard needs to render the connect/disconnect/status UI.

Phase 3 (the actual /slack/events webhook + agent loop) is the next chunk.

Crypto refactor (no behaviour change to LLM keys)

Extracted `encryptString` / `decryptString` from `llm-key-crypto.ts` as the generic primitive. LLM key wrappers reuse it via `KeyScope` salt; Slack bot token wrappers use `slack:<slack_team_id>` salt. The `llm-keys` integration tests pass unchanged.

New files

  • `src/slack-oauth.ts` — state HMAC, install URL builder, code exchange, signature verifier, token revoke.
  • `src/slack-workspace-service.ts` — workspace CRUD + audit events.
  • `tests/integration/slack-oauth.test.ts` — 20 tests.

New routes

Method Path Auth Notes
POST `/slack/install-url` admin returns `{ url, state, redirect_uri }`
GET `/slack/oauth/callback` signed state 302 to dashboard `?installed=1` or `?error=...`
GET `/slack/status` admin `{ configured, workspace
DELETE `/slack/uninstall` admin 200 + audit event, best-effort `auth.revoke`

The callback bypass in the global Bearer-auth hook is explicit and called out in the diff. Signed state IS the auth.

Tests

335/335 backend tests green (was 315; +20 new). Covers state HMAC roundtrip / tampering / expiry, Slack signature verification (valid / wrong / stale / future), all 4 HTTP routes (admin gating, success paths, error paths), audit-event recording.

Manual smoke (after dev deploy)

  1. Curl `POST /slack/install-url` with admin key → expect `{ url: 'https://slack.com/...', state: '...', redirect_uri: 'https://api-dev.reflectmemory.com/slack/oauth/callback' }`.
  2. Open the returned URL in a browser → Slack consent screen for the `Reflect Dev` app.
  3. Authorise → redirected to `https://dev.reflectmemory.com/dashboard/connections/slack?installed=1\`.
  4. Curl `GET /slack/status` → workspace metadata populated, no bot token in the response.
  5. Click Disconnect in dashboard → workspace soft-deleted, status returns null again.

Companion PR

Dashboard PR (proxies + UI) lands separately. Both must merge for the full UX.

Required env (already set on dev rm01 `.env`)

Test plan

  • CI green
  • Dev deploy succeeds, service boots cleanly
  • Curl install-url returns a slack.com URL
  • OAuth handshake completes against the real `Reflect Dev` app, workspace persists
  • Status / uninstall round-trip works

Made with Cursor

TS00 added 2 commits April 28, 2026 19:22
…ence

Second slice of the Slack app work. Stands up the OAuth handshake,
workspace persistence with encrypted bot tokens, and the four routes
the dashboard needs to render the connect/disconnect/status UI.

Crypto refactor (no behaviour change):
- Extract encryptString / decryptString from llm-key-crypto.ts as the
  generic primitive (HKDF-SHA256 sub-key derivation + AES-256-GCM).
- LLM key wrappers (encryptLlmKey / decryptLlmKey) now build their salt
  from the existing KeyScope and call into the generic primitive.
- New Slack wrappers (encryptSlackBotToken / decryptSlackBotToken) use
  salt='slack:<slack_team_id>' so each workspace's token has a distinct
  derived sub-key.

slack-workspace-service.ts:
- upsertSlackWorkspace (create or re-install), getActiveWorkspaceForTeam
  / forUser, getWorkspaceBySlackTeamId, getWorkspaceWithToken (decrypts
  the bot token; only used when actually calling Slack), softDelete-
  Workspace. All audit-logged: slack.installed, slack.reinstalled,
  slack.uninstalled.

slack-oauth.ts:
- signOauthState / verifyOauthState — opaque, AEAD-encrypted state with
  a 10-min TTL, carries the originating reflect_user_id. Reuses the
  master encryption key (no extra env var).
- loadSlackOauthConfig + getActiveSlackConfig — reads REFLECT_DEV_SLACK_*
  or REFLECT_PROD_SLACK_* depending on RM_SLACK_ENV.
- buildInstallUrl — composes the slack.com/oauth/v2/authorize URL with
  the bot scope set the v1 manifest declares.
- exchangeOauthCode — calls slack.com/api/oauth.v2.access with the code,
  returns workspace metadata + bot token on success.
- verifySlackSignature — HMAC-SHA256 with timing-safe compare and a
  5-min timestamp tolerance, ready for /slack/events in phase 3.
- revokeSlackToken — best-effort auth.revoke during uninstall.

HTTP routes (4 new, all admin-gated except the public callback):
- POST   /slack/install-url       -> { url, state, redirect_uri }
- GET    /slack/oauth/callback    -> 302 to dashboard ?installed=1
                                     (signed state replaces auth)
- GET    /slack/status            -> { configured, workspace | null }
- DELETE /slack/uninstall         -> 200 + audit, best-effort revoke

The /slack/oauth/callback bypass in the global Bearer-auth hook is
explicit; the signed state IS the auth.

Tests: +20 (335 total, all green).
- State HMAC roundtrip / tampering / expiry / bogus input.
- verifySlackSignature: valid / wrong sig / stale / future / non-numeric.
- POST /slack/install-url: 403 for non-admin, returns slack.com URL with
  the state echoed and parseable.
- GET /slack/status: 403 for non-admin, null when nothing installed,
  populated after a direct upsert (cross-process master key shared via
  .test-server.json so this works).
- GET /slack/oauth/callback error paths: missing code, bad state, slack
  error param all redirect to dashboard with error= query param.
- DELETE /slack/uninstall: 403 / 404 / 200 + audit event verified.

Refs: parent memory d959bc61 (Eng Plan: Slack App v1) + 3a2f27a3
(Phase 1 shipped).

Made-with: Cursor
The secret-scanning CI job matches the literal regex `xoxb-[a-zA-Z0-9-]+`
even on obviously-fake test fixtures. Build the fake tokens at runtime
so the source contains no matching literal, while the runtime values
remain identical and the tests still verify the same invariants.

Also tightens the JSON-response negative assertion to look for any
`xoxb-` prefix (also runtime-built), not just the specific fixture.

Made-with: Cursor
@TS00 TS00 merged commit f4a1311 into dev Apr 28, 2026
4 checks passed
@TS00 TS00 deleted the feat/slack-oauth-phase2 branch April 28, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant